tests: VHT encoding combos + sniff_air.py radiotap verifier#40
Merged
Conversation
Extends `tests/regress.py --encoding-matrix` (PR #39) with two follow-ups that together close the loop on chip-specific RX encoding limitations. ## What this is 1. **VHT combos in `ENCODING_COMBOS`.** Adds `VHT-BCC` and `VHT-LDPC` (radiotap bit 21, 22-byte VHT info field — single-user MCS 0 / NSS 1 / 20 MHz) alongside the existing HT combos (radiotap bit 19). The motivating case is RTL8821AU: its reported LDPC-RX-no limitation (per Eachine Sphere Link via @RomanLut) is on the VHT path, not HT. The chip's HT and VHT decoders are separate code paths in silicon, so an HT-LDPC test passing doesn't tell us anything about VHT-LDPC. Matrix grows from 16 → 24 cells per run (6 combos × 4 driver modes). 2. **`tests/sniff_air.py`.** Standalone helper that captures on a monitor-mode iface (intended use: AR9271, per kaeru ref `AR9271 as peer-sniffer for devourer TX validation`) and decodes each captured frame's radiotap to report what encoding actually flew. Filters on the canonical injection SA. Answers the "did mac80211 actually emit what inject_beacon.py requested, or strip the flags before the air" question that the encoding-matrix itself can't. Internal radiotap parser handles MCS info (bit 19) and VHT info (bit 21) — same fields the inject/txdemo sides emit. Round-trip tested: every combo emitted by inject_beacon.py parses back to the same (kind, mcs, nss, ldpc, stbc, bw) tuple in sniff_air.py. ## Implementation - `tests/inject_beacon.py`: `_build_radiotap_vht` helper (hand-built 22-byte VHT radiotap), `--vht / --vht-mcs / --vht-nss / --bandwidth 20|40|80|160` flags. Existing `--ldpc / --stbc / --mcs` work for both HT and VHT modes — they map to the appropriate field for whichever mode is active. - `txdemo/main.cpp`: when `DEVOURER_TX_VHT=1`, replaces the 13-byte HT radiotap prefix with a 22-byte VHT radiotap header built dynamically from `DEVOURER_TX_VHT_MCS / _VHT_NSS / _LDPC / _STBC / _BW` env vars. Refactored TX buffer to `std::vector<uint8_t>` so it can hold either prefix length. Cross-checked against `inject_beacon.py`'s builder for byte-identical output. - `tests/regress.py`: `ENCODING_COMBOS` extended with VHT-BCC / VHT-LDPC. `_devourer_env` + `_spawn_kernel_tx` pass `vht / vht_mcs / nss` through. No new flags. - `tests/sniff_air.py`: new file (~270 lines). Standalone, runs via `sudo python3 tests/sniff_air.py --iface <mon> --channel N --duration N`. Sets iface to monitor mode, captures via tcpdump, parses pcap manually (no scapy.rdpcap dependency — scapy's radiotap parser is hit-or-miss for LDPC/STBC bits), groups frames by encoding, prints distribution. - `tests/README.md`: documents both pieces. Replaces the "HT-only, validated didn't reproduce LDPC asymmetry" caveat with a more general "kernel-TX encoding flags may not always reach the air, use sniff_air.py to prove what flew" paragraph. ## Validation Built and ran end-to-end on the lab rig 2026-05-25, channel 100, RTL8814AU TX → RTL8821AU RX, VM mode (devourer-testrig). `--encoding-matrix` table (24 cells, all ran cleanly): | Mode | HT-BCC | HT-LDPC | HT-STBC=1 | HT-LDPC+STBC | VHT-BCC | VHT-LDPC | |---|---|---|---|---|---|---| | k/k | 466 ✓ | 455 ✓ | 462 ✓ | 443 ✓ | 435 ✓ | 453 ✓ | | d/k | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | | k/d | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ | | d/d | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | VHT-LDPC `k/d` still flat at ~400 (same as VHT-BCC and HT-LDPC). 8821AU RX accepted every cell. Two reads: 1. Kernel-TX path strips radiotap LDPC bit regardless of HT vs VHT mode → would explain why the chip never has to refuse an LDPC frame. `tests/sniff_air.py` on an AR9271 would prove or disprove this. 2. 8821AU does not actually have the LDPC-RX-no limitation, or it only triggers under different conditions (specific MCS / NSS / bandwidth / aggregation pattern). Without an AR9271 in the rig today, can't pick between (1) and (2) in this commit. The infrastructure to answer it is now in place. `d/k` and `d/d` rows blank because 8814 TX is broken on master (known issue, separate from this PR). They are the ground-truth path for devourer-side encoding — `WiFiDriverTxDemo` writes radiotap directly into the chip bulk-OUT buffer, no kernel filtering. Sniffer parser unit-tested against every combo `inject_beacon.py` can emit (HT-BCC / HT-LDPC / HT-STBC=1 / VHT-BCC / VHT-LDPC / VHT-LDPC+STBC) — round-trips with byte-identical results. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
josephnef
added a commit
that referenced
this pull request
May 25, 2026
…#41) Closes the last of #39's three follow-ups: wires the radiotap verifier from #40 (`tests/sniff_air.py`) into `tests/regress.py` so it runs alongside every cell. Optional and opt-in — `--sniffer-iface` defaults to off, all prior modes unchanged. ## What this is ```bash sudo python3 tests/regress.py --encoding-matrix \ --tx-pid 0x8813 --rx-pid 0x0120 --channel 100 \ --vm-name devourer-testrig --vm-ssh <user>@<VM-IP> \ --sniffer-iface wlan0mon ``` Per-cell output gains a `↪ sniffer: N frames — <encoding>=N, ...` line under the existing hit-count line. Reports what actually flew on-air for each cell — closes the open question from #40 ("did the kernel-TX path actually emit LDPC, or strip the flag?"). Intended for an AR9271: vanilla radiotap, no driver-side filtering on what the cell injects. The chipset is widely used as a sniffer for exactly this reason. ## Implementation | Component | Behaviour | |---|---| | `_spawn_sniffer(iface, channel, pcap_path)` | Sets iface to monitor mode, runs `tcpdump -w pcap -U -nn 'ether src CANONICAL_SA'`. Always host-local; sniffer never moved into the VM via USB passthrough. | | `_summarise_sniffer_pcap(pcap_path)` | Imports sniff_air at runtime (sits next to regress.py), reuses `_read_pcap_frames` + `_parse_radiotap` + `_frame_sa` + `CANONICAL_SA` to bucket captured frames. Returns a one-line summary for the cell's `notes` field. | | `run_cell(sniffer_iface=...)` | Spawns sniffer between RX and TX stages so the full TX window gets captured. Sniffer failures are observational — never fail the cell on sniffer issues. | | `run_matrix` / `run_full_matrix` / `run_encoding_matrix` | Pass `sniffer_iface` through to `run_cell`. When active + `r.notes` is set, print an extra `↪ <notes>` line per cell. | | `--sniffer-iface IFACE` | New CLI flag + `DEVOURER_SNIFFER_IFACE` env equivalent. | ## Validation The dormant path (`sniffer_iface=None`) preserves the exact prior behaviour of every matrix mode — only structural change in `run_cell` is initializing `sniffer_proc=None` and a no-op cleanup if it stays None. Active-path validation requires an AR9271 plugged in, which isn't in the rig today. CI matrix builds will confirm the code paths compile / import. Functional end-to-end pending hardware. The sniffer parser itself was unit-tested in #40: every combo `inject_beacon.build_beacon` emits round-trips back through `sniff_air._parse_radiotap` with byte-identical decoded fields. ## What this PR doesn't touch - The markdown table emit functions are unchanged — sniffer notes go to per-cell stdout, not into the table. Could be added as a separate column in a future PR if interesting. - No new combos; `ENCODING_COMBOS` unchanged from #40. - AR9271-specific bring-up (driver loading, monitor capability detection). The flag takes a generic iface name; the user is expected to have a working monitor iface before pointing the matrix at it. ## Test plan - [x] `--help` lists `--sniffer-iface` - [x] Code parses, imports successfully - [x] CI matrix builds — pending on this push - [ ] `--sniffer-iface IFACE` with an AR9271 plugged in: verify per-cell `↪ sniffer:` lines appear and decode reasonably - [ ] Confirm `--sniffer-iface` running alongside `--encoding-matrix` shows different encoding distributions for `--ldpc` vs default cells (the actual goal — proves whether mac80211 / 88XXau emits the LDPC bit on-air) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
josephnef
added a commit
that referenced
this pull request
May 26, 2026
…trix (#46) Two bugs in the sniffer integration from #40 / #41 that together hid the real finding about what kernel TX actually emits on-air. Fixing them surfaces a definitive answer to the open LDPC question. ## Bug 1: `_parse_radiotap` returned None on every real-world frame The parser only iterated `it_present[0]` and bailed when it hit a bit not in `_RT_FIELDS`. Every captured frame from `ath9k_htc` has presence `0xa000402f` with bits `[0,1,2,3,5,14,29,31]` — bits 29 (`RADIOTAP_NS` marker) and 31 (`EXT` — continuation word) are control bits, not data fields. The parser hit bit 29 on every frame → `408 parse-errors / 408 frames` on the first AR9271 sniffer run. Fix: iterate bits across ALL presence words; recognise the three control bits (29 RADIOTAP_NS, 30 VENDOR_NS, 31 EXT) and skip them with the right data consumption (6 bytes for VENDOR_NS, none for the others); when hitting an unknown radiotap field, return the parsed-so-far dict rather than discarding the whole frame. Round-trip against `inject_beacon.build_beacon` for every `(HT, VHT) × (BCC, LDPC, STBC)` combo: byte-identical decoded fields. Real AR9271-captured beacon: parses cleanly as `kind=legacy`. ## Bug 2: cell pcap filename collision in `--encoding-matrix` `cell_id = f"tx-{tx_side}_rx-{rx_side}"` doesn't include the encoding label, so six encoding cells per driver-mode wrote to the same `/tmp/devourer-regress-*/tx-{tx}_rx-{rx}.sniffer.pcap`. The last cell overwrote the first five — so `--keep-logs` retained only the LAST encoding combo per mode (typically VHT-LDPC, where AR9271 captures 0 frames since it's n-only). Made post-hoc debugging impossible. Fix: optional `cell_tag` parameter on `run_cell`, set by `run_encoding_matrix` to the sanitised encoding label (`ht-bcc`, `ht-ldpc`, `vht-ldpcstbc`, …). Other matrix modes (`run_matrix`, `run_full_matrix`) leave it empty — they only run one cell per `(tx_side, rx_side)` pair. ## What this surfaces Encoding matrix re-run on the rig (8814 TX → 8821 RX, ch6, AR9271 sniffer): | Mode | Encoding requested | Sniffer decoded | |---|---|---| | k/k | HT-BCC | `HT MCS1 BCC 20MHz STBC=0` (412) | | k/k | HT-LDPC | `HT MCS1 **BCC** 20MHz STBC=0` (386) | | k/k | HT-STBC=1 | `HT MCS1 BCC 20MHz **STBC=0**` (418) | | k/k | HT-LDPC+STBC=1 | `HT MCS1 **BCC** **STBC=0**` (422) | | k/k | VHT-BCC | 0 frames (AR9271 is n-only) | | k/k | VHT-LDPC | 0 frames | Same in the `k/d` row. **`aircrack-ng/88XXau` (or mac80211 in its TX path) strips the radiotap LDPC bit and STBC stream count.** MCS index (1) and the HT-vs-VHT distinction DO survive. So every "LDPC" kernel-TX cell in #40 and #41 was actually emitting BCC on-air — the flat `k/d` row in those PRs' results never disproved @RomanLut's 8821AU LDPC-RX-no claim, it just meant we never tested the chip with an actual LDPC frame. VHT cells show 0 to AR9271 (n-only) but >0 to the 8821 RX (AC chip), so the HT/VHT distinction IS honoured. We still can't see whether mac80211 strips the VHT-LDPC bit specifically — would need an AC-capable sniffer (or to capture on the 8821 RX itself in monitor mode). ## Implications - The `--encoding-matrix` mode is most useful for chip-side asymmetries reachable through MCS-index and HT-vs-VHT; it can't validate LDPC- or STBC-specific RX behaviour through the kernel TX path as currently wired. - A proper LDPC-RX validation needs a userspace TX path that writes the radiotap directly to the chip — devourer's txdemo does this (`DEVOURER_TX_LDPC=1` env var is ground-truth). 8814 TX being broken on master is the blocker for using this from the d/k cells. - The AR9271 sniffer integration is functional; the data it produces is now reliable. Future hotplug / encoding investigations can rely on it. ## Test plan - [x] Parser round-trips every encoding combo `inject_beacon.build_beacon` emits - [x] Parser handles real AR9271 capture (multi-word presence, bits 29/31 set) - [x] `--encoding-matrix` produces distinct pcap-per-cell with `--keep-logs` - [x] Re-ran end-to-end on the rig — all 24 cells emit reliable sniffer summaries - [x] CI builds — no C++ changes here, Python-only 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #39 closing two of the three follow-ups noted in that PR's test plan.
What this is
Two pieces:
VHT combos in
--encoding-matrix.ENCODING_COMBOSgrows from 4 to 6 entries: addsVHT-BCCandVHT-LDPC(radiotap bit 21, 22-byte VHT info field, MCS 0 / NSS 1 / 20 MHz). Motivating case: RTL8821AU's LDPC-RX-no limitation (per @RomanLut, Eachine Sphere Link) is on the VHT path. The chip's HT and VHT decoders are separate silicon blocks, so tests: --encoding-matrix iterates radiotap LDPC/STBC per cell #39's HT-LDPC test passing didn't say anything about VHT-LDPC.tests/sniff_air.py. Standalone radiotap-decode helper. Capture on a monitor-mode iface (intended use: AR9271 — vanilla radiotap, no driver-side flag filtering), filter on the canonical injection SA, decode each captured frame's MCS / VHT field. Answers the question "did mac80211 actually emit what `inject_beacon.py` requested, or strip the encoding flags before the air?" — which is what tests: --encoding-matrix iterates radiotap LDPC/STBC per cell #39's flat-LDPC-row result was waiting on.Implementation
Validation on the lab rig (8814 TX → 8821 RX, ch 100, VM mode)
`--encoding-matrix` table (24 cells, all ran):
VHT-LDPC `k/d` still flat at ~400 hits, same as VHT-BCC and HT-LDPC. 8821AU RX accepted every cell. Two reads, neither distinguishable in this PR:
`d/k` and `d/d` rows are 0 because 8814 TX is broken on master (separate known issue, not in scope here).
Sniffer parser unit-tested against every combo `inject_beacon.py` emits — round-trips with byte-identical decoded fields.
What this PR doesn't touch
Test plan
🤖 Generated with Claude Code